fix(scheduling): drive scheduled publishing from a real heartbeat#1312
fix(scheduling): drive scheduled publishing from a real heartbeat#1312ascorbic wants to merge 3 commits into
Conversation
) Scheduled content never transitioned to published: nothing called the publishing machinery. Add a publishDueContent sweep (sibling to system cleanup) that promotes due content, driven by a real heartbeat rather than request side effects. - Node: the timer-based scheduler runs the sweep alongside its tick. - Cloudflare: a scheduled() handler wired to a Cron Trigger runs EmDashRuntime.runScheduledTasks(); the request-driven PiggybackScheduler is removed. Ship @emdash-cms/cloudflare/worker (default export = Astro handler + the scheduled() handler, re-exporting PluginBridge) plus createScheduledHandler(). The adapter is externalized so its build-time virtual resolves in the consuming app's Astro build. The handler purges edge-cache tags for published content via the configured Astro cache provider. Templates collapse worker.ts to a one-line re-export and add the Cron Trigger.
…the publish sweep Replace the navigator.userAgent "Cloudflare-Workers" runtime sniff in EmDashRuntime with a build-time createScheduler factory injected via a new virtual:emdash/scheduler module (parallel to virtual:emdash/wait-until). The adapter decision lives in the integration, keyed off astroConfig.adapter?.name and the Vite command, so core has no Cloudflare-specific runtime path. Local astro dev keeps the Node timer even under the Cloudflare adapter, where production relies on the Cron Trigger. Also harden the scheduled-publish sweep, per adversarial review: - Fire content:afterPublish hooks by routing the sweep through the runtime wrapper instead of the raw DB handler (search indexing, webhooks, etc.). - Record the scheduled time as published_at on first publication rather than the later sweep time. - Claim each due row atomically with a single conditional UPDATE before promoting it, preventing publish-after-unschedule and double-publish across overlapping sweeps. Restore the schedule if post-claim work fails on a driver without transactions (D1) so the row stays retryable.
🦋 Changeset detectedLatest commit: be91319 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Scope checkThis PR changes 1,203 lines across 31 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
PR template validation failedPlease fix the following issues by editing your PR description:
See CONTRIBUTING.md for the full contribution policy. |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | be91319 | Jun 03 2026, 11:34 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | be91319 | Jun 03 2026, 11:34 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | be91319 | Jun 03 2026, 11:34 AM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
Pull request overview
Adds a true scheduled-maintenance heartbeat so scheduled content is actually promoted to published, and refactors scheduler selection to be platform-injected (build-time) rather than runtime-sniffed—keeping core adapter-agnostic while enabling Cloudflare Cron Triggers to drive scheduled work.
Changes:
- Introduces a scheduled publishing sweep (
publishDueContent) and wires it into a new runtime-level scheduled batch (EmDashRuntime.runScheduledTasks()). - Replaces request-driven Cloudflare piggyback scheduling with a Worker
scheduled()handler + template Cron Triggers; addsvirtual:emdash/schedulerto inject timer scheduling only where appropriate. - Hardens scheduled publishing via an atomic due-claim gate (
requireScheduledDue) and adds unit coverage around the new behavior and adapter/dev matrix.
Reviewed changes
Copilot reviewed 29 out of 31 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| templates/starter-cloudflare/wrangler.jsonc | Adds Cron Trigger to drive scheduled maintenance. |
| templates/starter-cloudflare/src/worker.ts | Switches template worker entry to re-export the new packaged Worker entry. |
| templates/portfolio-cloudflare/wrangler.jsonc | Adds Cron Trigger to drive scheduled maintenance. |
| templates/portfolio-cloudflare/src/worker.ts | Switches template worker entry to re-export the new packaged Worker entry. |
| templates/marketing-cloudflare/wrangler.jsonc | Adds Cron Trigger to drive scheduled maintenance. |
| templates/marketing-cloudflare/src/worker.ts | Switches template worker entry to re-export the new packaged Worker entry. |
| templates/blog-cloudflare/wrangler.jsonc | Adds Cron Trigger to drive scheduled maintenance. |
| templates/blog-cloudflare/src/worker.ts | Switches template worker entry to re-export the new packaged Worker entry. |
| pnpm-lock.yaml | Updates lockfile for dependency graph changes (incl. adapter peer/dev deps). |
| packages/core/vitest.config.ts | Stubs virtual:emdash/scheduler for unit tests. |
| packages/core/tests/unit/scheduled-publish.test.ts | Adds unit coverage for the sweep, runtime scheduled batch, and due-claim gate. |
| packages/core/tests/unit/astro/middleware-prerender.test.ts | Mocks virtual:emdash/scheduler for middleware tests. |
| packages/core/tests/unit/astro/integration/virtual-modules.test.ts | Tests scheduler virtual-module output across adapter + dev/build command matrix. |
| packages/core/src/virtual-modules.d.ts | Declares virtual:emdash/scheduler type contract. |
| packages/core/src/scheduled-publish.ts | Implements the scheduled publishing sweep. |
| packages/core/src/plugins/scheduler/piggyback.ts | Removes request-driven piggyback scheduler implementation. |
| packages/core/src/plugins/index.ts | Exposes Node scheduler and scheduler types from plugins entrypoint. |
| packages/core/src/plugins/cron.ts | Updates docstring to reflect new scheduling drivers. |
| packages/core/src/index.ts | Exposes Node scheduler + scheduler types from the public API surface. |
| packages/core/src/emdash-runtime.ts | Adds scheduled batch runner, injects scheduler creation, wires sweep + cleanup into heartbeat. |
| packages/core/src/database/repositories/types.ts | Introduces ScheduledNotDueError for due-claim gate behavior. |
| packages/core/src/database/repositories/content.ts | Adds atomic due-claim gate to publish() + restore-on-failure behavior. |
| packages/core/src/astro/types.ts | Extends publish handler options to include requireScheduledDue. |
| packages/core/src/astro/middleware.ts | Exposes runScheduledTasks() for request-less drivers (e.g. CF scheduled()). |
| packages/core/src/astro/integration/vite-config.ts | Serves the new virtual:emdash/scheduler module from the integration plugin. |
| packages/core/src/astro/integration/virtual-modules.ts | Generates scheduler virtual module based on adapter + Vite command. |
| packages/core/src/api/handlers/content.ts | Plumbs requireScheduledDue into ContentRepository.publish() and maps ScheduledNotDueError to NOT_DUE. |
| packages/cloudflare/tsdown.config.ts | Adds Worker entry build + marks Astro adapter/entrypoints as external. |
| packages/cloudflare/src/worker.ts | Adds packaged Worker entry that wraps Astro fetch handler + scheduled() maintenance. |
| packages/cloudflare/package.json | Exposes ./worker entry and adds adapter peer/dev dependency. |
| .changeset/scheduled-publishing-driver.md | Documents the feature and Cloudflare migration steps in a changeset. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // start() is void on the timer scheduler but the interface | ||
| // allows a promise (alarm-backed schedulers); we don't block on it. | ||
| void scheduler.start(); |
| ctx.waitUntil( | ||
| runScheduledTasks() | ||
| .then(async ({ published }) => { | ||
| await invalidatePublishedTags(published); | ||
| if (published.length > 0) { | ||
| console.log(`[scheduled] Published ${published.length} scheduled item(s)`); | ||
| } | ||
| return undefined; | ||
| }) | ||
| .catch((error: unknown) => { | ||
| console.error("[scheduled] runScheduledTasks failed:", error); | ||
| }), |
There was a problem hiding this comment.
This PR fixes a real and longstanding bug: scheduled content never transitioned to published because nothing actually drove the publishing machinery. The approach is sound—replacing the request-piggyback PiggybackScheduler with a proper platform heartbeat (Node timer for long-lived runtimes, Cloudflare Cron Trigger for Workers) and wiring the choice through a virtual module so core stays adapter-agnostic.
What I checked:
- Full diff, key new files (
scheduled-publish.ts,cloudflare/src/worker.ts), and all changed core files. - SQL safety (all queries use Kysely tagged templates with
sql.ref()for identifiers; no interpolation). - API envelope consistency (
handleContentPublishreturnsApiResult, newNOT_DUEerror code is surfaced correctly). - Authorization (the publish route still checks
content:publish_own/content:publish_any; the sweep bypasses auth because it's an internal maintenance task, which is correct). - Locale filtering (
findReadyToPublishcorrectly returns all locale rows; each row is a per-locale entry and gets published individually—no missing locale filter). - Index discipline (the existing partial index on
scheduled_atcovers the sweep query). - Changeset and tests (present and comprehensive: scheduler generation matrix,
published_atprovenance, atomic claim, double-publish guard, restore-on-failure).
Headline conclusion: The core implementation is solid. The atomic claim + best-effort restore pattern correctly hardens the sweep for D1's lack of multi-statement transactions, and routing through runtime.handleContentPublish ensures hooks fire identically to manual publishes. I have one concern about a zero-arity createApp() call in the Cloudflare worker that the author should verify.
What is still open / to verify:
packages/cloudflare/src/worker.tscallscreateApp()without a manifest argument. In standard Astro this function requires a manifest; if it fails here, edge-cache invalidation in thescheduled()handler will silently no-op (the error is caught, but tags are never purged). The author should confirmastro/app/entrypointsupports zero-arity invocation in the Cloudflare adapter build context.
| published: ReadonlyArray<{ collection: string; id: string }>, | ||
| ): Promise<void> { | ||
| if (published.length === 0) return; | ||
| app ??= createApp(); |
There was a problem hiding this comment.
[suggestion] createApp() is called with no arguments here. Astro's createApp typically requires a manifest (createApp(manifest)). If astro/app/entrypoint does not provide a zero-arity overload, this will throw at runtime inside the scheduled() handler. The error is caught by the outer .catch(), so the worker won't crash, but edge-cache tag invalidation for published content will silently never run.
Please verify that the Cloudflare adapter build pipeline injects the manifest automatically for astro/app/entrypoint consumers. If not, pass the manifest explicitly (or construct the cache provider directly) so invalidatePublishedTags actually purges stale cache entries.
| app ??= createApp(); | |
| app ??= createApp(/* manifest if required */); |
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
What does this PR do?
Scheduled content never transitioned to
publishedbecause nothing drove the publishing machinery. This PR adds a real heartbeat that promotes due content, and wires the platform-specific scheduler choice cleanly so core stays adapter-agnostic.Closes #1303
1. Drive scheduled publishing from a heartbeat (commit
67a7b33c)publishDueContentsweep promotes due content alongside the cron tick and system cleanup.scheduled()handler wired to a Cron Trigger runsrunScheduledTasks(); the request-drivenPiggybackScheduleris removed (no maintenance side effects on visitor requests).@emdash-cms/cloudflare/worker(default export = Astro handler +scheduled(), re-exportingPluginBridge) pluscreateScheduledHandler(). Purges edge-cache tags for published content via the configured cache provider. Templates collapseworker.tsto a one-line re-export and add the Cron Trigger.2. Inject the scheduler from the platform, not a runtime sniff (commit
139f6f1e)navigator.userAgent === "Cloudflare-Workers"runtime check fromEmDashRuntimein favour of a build-timecreateSchedulerfactory injected via a newvirtual:emdash/schedulermodule (parallel tovirtual:emdash/wait-until). The adapter decision lives in the integration, keyed offastroConfig.adapter?.nameand the Vite command. Core has no Cloudflare-specific runtime path.astro devunder the Cloudflare adapter would never run the timer (no Cron Trigger fires in dev) — dev keeps the Node timer; only the production CF build defers to the Cron Trigger.3. Harden the sweep (commit
139f6f1e, from an adversarial review pass)content:afterPublishhooks by routing the sweep through the runtime wrapper, so scheduled and manual publishes behave identically (search indexing, webhooks, syndication).published_aton first publication, not the later sweep time.UPDATE) before promoting it: prevents publishing an entry that was unscheduled/rescheduled just before its time, and prevents double-publish across overlapping sweeps. Restore the schedule if post-claim work fails on a driver without transactions (D1), so the row stays retryable.Type of change
(Also bundles a no-behaviour-change refactor of the scheduler selection and follow-up correctness fixes for the same feature.)
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runAI-generated code disclosure
Screenshots / test output
Full suite green: build,
typecheck,lint,format, 3600 unit tests pass. New tests cover the scheduler module dev/build matrix,published_atprovenance, the atomic due-claim, double-publish guard, and restore-on-failure (positive and negative).Try this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
feat/scheduled-publishing-driver. Updated automatically when the playground redeploys.